@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.
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Run the Python self-test with a discovered interpreter, so `npm run selftest`
4
+ * works the same way on every platform.
5
+ *
6
+ * The self-test needs a real MATLAB, and it starts one; it is not part of CI.
7
+ *
8
+ * npm run selftest
9
+ * npm run selftest -- --no-debug
10
+ */
11
+
12
+ import { spawnSync } from 'node:child_process'
13
+ import { existsSync } from 'node:fs'
14
+ import { dirname, join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import process from 'node:process'
17
+
18
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
19
+ const SELFTEST = join(PACKAGE_ROOT, 'python', 'selftest.py')
20
+ const PYLIBS = join(PACKAGE_ROOT, 'python', 'pylibs')
21
+
22
+ function findPython() {
23
+ const dirs = (process.env.PATH || '').split(process.platform === 'win32' ? ';' : ':')
24
+ const suffixes = process.platform === 'win32' ? ['', '.exe'] : ['']
25
+ for (const name of ['python3', 'python']) {
26
+ for (const dir of dirs) {
27
+ if (dir === '') continue
28
+ for (const suffix of suffixes) {
29
+ const candidate = join(dir, name + suffix)
30
+ if (existsSync(candidate)) return candidate
31
+ }
32
+ }
33
+ }
34
+ return null
35
+ }
36
+
37
+ const python = findPython()
38
+ if (python === null) {
39
+ console.error('no Python interpreter found on PATH')
40
+ process.exit(1)
41
+ }
42
+ if (!existsSync(join(PYLIBS, 'matlab'))) {
43
+ console.error('engine runtime missing; run `npm run setup` first')
44
+ process.exit(1)
45
+ }
46
+
47
+ // Report the interpreter without capturing a pipe: DSH's file sandbox refuses
48
+ // named pipes, so `execFileSync(..., { stdio: 'pipe' })` fails with EPERM
49
+ // inside a DSH session. `inherit` needs no pipe.
50
+ console.log('python: ' + python)
51
+ const probe = spawnSync(python, ['--version'], { stdio: 'inherit' })
52
+ if (probe.status !== 0) {
53
+ console.error('failed to run ' + python)
54
+ process.exit(1)
55
+ }
56
+
57
+ const result = spawnSync(python, ['-W', 'ignore', SELFTEST, ...process.argv.slice(2)], {
58
+ stdio: 'inherit',
59
+ env: { ...process.env, PYTHONPATH: PYLIBS },
60
+ })
61
+ process.exit(result.status === null ? 1 : result.status)
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Lay out the MATLAB Engine for Python runtime that ships with a local MATLAB
4
+ * installation, so the bridge driver can `import matlab.engine`.
5
+ *
6
+ * WHY THIS STEP EXISTS
7
+ * The engine is not on PyPI for this MATLAB release, and it is MathWorks code
8
+ * that must not be redistributed. So the package ships without it and this
9
+ * script copies it out of the MATLAB installation the user already has.
10
+ *
11
+ * WHY NOT `pip install`
12
+ * The bundled engine advertises Python 3.9-3.12 and ships a stable-ABI (`abi3`)
13
+ * extension module. On Windows its `setup.py` also generates an `_arch.txt`
14
+ * that tells the engine where MATLAB's `bin` and `extern/bin` DLL directories
15
+ * are. A directory copy plus that generated file is exactly what the wheel
16
+ * would have produced, without needing a build toolchain.
17
+ *
18
+ * Usage:
19
+ * node scripts/setup-engine.mjs
20
+ * node scripts/setup-engine.mjs --matlab-root <matlab-root>
21
+ * node scripts/setup-engine.mjs --python python3.12 --force
22
+ */
23
+
24
+ import { spawnSync } from 'node:child_process'
25
+ import { cp, mkdir, rm, writeFile } from 'node:fs/promises'
26
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
27
+ import { tmpdir } from 'node:os'
28
+ import { dirname, join } from 'node:path'
29
+ import { fileURLToPath } from 'node:url'
30
+ import process from 'node:process'
31
+
32
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
33
+ const DEST = join(PACKAGE_ROOT, 'python', 'pylibs')
34
+
35
+ /** MATLAB's own architecture directory names, keyed by platform. */
36
+ function archDirFor(platform, arch) {
37
+ if (platform === 'win32') return 'win64'
38
+ if (platform === 'linux') return 'glnxa64'
39
+ if (platform === 'darwin') return arch === 'arm64' ? 'maca64' : 'maci64'
40
+ return null
41
+ }
42
+
43
+ function parseArgs(argv) {
44
+ const options = { matlabRoot: null, python: null, force: false, help: false }
45
+ for (let i = 0; i < argv.length; i += 1) {
46
+ const arg = argv[i]
47
+ if (arg === '--help' || arg === '-h') options.help = true
48
+ else if (arg === '--force' || arg === '-f') options.force = true
49
+ else if (arg === '--matlab-root') options.matlabRoot = argv[++i] ?? null
50
+ else if (arg === '--python') options.python = argv[++i] ?? null
51
+ else if (arg.startsWith('--matlab-root=')) options.matlabRoot = arg.slice('--matlab-root='.length)
52
+ else if (arg.startsWith('--python=')) options.python = arg.slice('--python='.length)
53
+ else throw new Error('unknown argument: ' + arg)
54
+ }
55
+ return options
56
+ }
57
+
58
+ /** Locate an executable by scanning PATH, without assuming a shell. */
59
+ function findOnPath(names) {
60
+ const dirs = (process.env.PATH || '').split(process.platform === 'win32' ? ';' : ':')
61
+ const suffixes = process.platform === 'win32' ? ['', '.exe', '.cmd', '.bat'] : ['']
62
+ for (const name of names) {
63
+ for (const dir of dirs) {
64
+ if (dir === '') continue
65
+ for (const suffix of suffixes) {
66
+ const candidate = join(dir, name + suffix)
67
+ if (existsSync(candidate)) return candidate
68
+ }
69
+ }
70
+ }
71
+ return null
72
+ }
73
+
74
+ /** Release directories worth probing under a platform's install root. */
75
+ function defaultInstallRoots() {
76
+ if (process.platform === 'win32') {
77
+ const roots = []
78
+ for (const base of [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], 'C:\\Program Files']) {
79
+ if (base) roots.push(join(base, 'MATLAB'))
80
+ }
81
+ return roots
82
+ }
83
+ if (process.platform === 'darwin') return ['/Applications']
84
+ return ['/usr/local/MATLAB', '/opt/MATLAB', '/usr/local']
85
+ }
86
+
87
+ /**
88
+ * Pick the newest release directory under one install root. Names sort
89
+ * lexicographically well enough for `R2023b`-style releases, and a plain
90
+ * version directory is accepted too.
91
+ */
92
+ function newestRelease(root, matcher) {
93
+ if (!existsSync(root)) return null
94
+ const entries = readdirSync(root, { withFileTypes: true })
95
+ .filter((entry) => entry.isDirectory() && matcher(entry.name))
96
+ .map((entry) => entry.name)
97
+ .sort()
98
+ if (entries.length === 0) return null
99
+ return join(root, entries[entries.length - 1])
100
+ }
101
+
102
+ function findMatlabRoot(explicit) {
103
+ if (explicit) return explicit
104
+ if (process.env.MATLAB_ROOT) return process.env.MATLAB_ROOT
105
+
106
+ // A `matlab` on PATH resolves to <root>/bin/matlab[.exe].
107
+ const exe = findOnPath(['matlab'])
108
+ if (exe !== null) {
109
+ const root = dirname(dirname(exe))
110
+ if (existsSync(join(root, 'extern', 'engines', 'python'))) return root
111
+ }
112
+
113
+ for (const base of defaultInstallRoots()) {
114
+ if (process.platform === 'darwin') {
115
+ const app = newestRelease(base, (n) => n.startsWith('MATLAB_R'))
116
+ if (app !== null) return app
117
+ } else {
118
+ const release = newestRelease(base, (n) => /^R\d{4}[ab]$/.test(n) || /^\d/.test(n))
119
+ if (release !== null) return release
120
+ }
121
+ }
122
+ return null
123
+ }
124
+
125
+ function findPython(explicit) {
126
+ if (explicit) return explicit
127
+ return findOnPath(['python3', 'python'])
128
+ }
129
+
130
+ /**
131
+ * Run a Python program and read back what it wrote to a temp file.
132
+ *
133
+ * Capturing a child's output through a PIPE is deliberately avoided: DSH runs
134
+ * commands inside a file sandbox that refuses named pipes, so the usual
135
+ * `execFileSync(..., { stdio: 'pipe' })` fails with EPERM there. `inherit`
136
+ * needs no pipe, and the child's own traceback still reaches the terminal.
137
+ */
138
+ function runPython(python, program) {
139
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-matlab-setup-'))
140
+ const outFile = join(dir, 'result.txt')
141
+ const full = [
142
+ program,
143
+ 'open(' + JSON.stringify(outFile) + ', "w", encoding="utf-8").write(RESULT)',
144
+ ].join('\n')
145
+ const result = spawnSync(python, ['-c', full], { stdio: 'inherit' })
146
+ let text = null
147
+ try {
148
+ text = readFileSync(outFile, 'utf8')
149
+ } catch {
150
+ text = null
151
+ }
152
+ rmSync(dir, { recursive: true, force: true })
153
+ return { status: result.status, text }
154
+ }
155
+
156
+ /** Major/minor version of an interpreter, or null when it will not run. */
157
+ function pythonVersion(python) {
158
+ const result = runPython(python, 'import sys\nRESULT = "%d.%d" % sys.version_info[:2]')
159
+ return result.status === 0 && result.text !== null ? result.text.trim() : null
160
+ }
161
+
162
+ async function main() {
163
+ const options = parseArgs(process.argv.slice(2))
164
+ if (options.help) {
165
+ console.log('Usage: node scripts/setup-engine.mjs [--matlab-root DIR] [--python EXE] [--force]')
166
+ return 0
167
+ }
168
+
169
+ const arch = archDirFor(process.platform, process.arch)
170
+ if (arch === null) {
171
+ throw new Error('unsupported platform: ' + process.platform + '/' + process.arch)
172
+ }
173
+
174
+ const matlabRoot = findMatlabRoot(options.matlabRoot)
175
+ if (matlabRoot === null) {
176
+ throw new Error('could not find a MATLAB installation. Pass --matlab-root <dir> or set MATLAB_ROOT.')
177
+ }
178
+ const engineSource = join(matlabRoot, 'extern', 'engines', 'python', 'dist', 'matlab')
179
+ if (!existsSync(engineSource)) {
180
+ throw new Error('no bundled Python engine under ' + matlabRoot
181
+ + '. MATLAB ships it in extern/engines/python; check that this install is complete.')
182
+ }
183
+
184
+ if (existsSync(DEST) && !options.force) {
185
+ console.log('engine runtime already present at ' + DEST)
186
+ console.log('re-run with --force to lay it out again')
187
+ } else {
188
+ await rm(DEST, { recursive: true, force: true })
189
+ await mkdir(DEST, { recursive: true })
190
+ await cp(engineSource, join(DEST, 'matlab'), { recursive: true })
191
+ // The four lines engine/__init__.py reads to find MATLAB's native
192
+ // libraries: architecture, bin, the engine's own module dir, extern/bin.
193
+ const archFile = [
194
+ arch,
195
+ join(matlabRoot, 'bin', arch),
196
+ join(DEST, 'matlab', 'engine', arch),
197
+ join(matlabRoot, 'extern', 'bin', arch),
198
+ ].join('\n') + '\n'
199
+ await writeFile(join(DEST, 'matlab', 'engine', '_arch.txt'), archFile)
200
+ console.log('engine runtime -> ' + DEST)
201
+ console.log('matlab root -> ' + matlabRoot)
202
+ console.log('architecture -> ' + arch)
203
+ }
204
+
205
+ const python = findPython(options.python)
206
+ if (python === null) {
207
+ console.log('')
208
+ console.log('No Python interpreter found on PATH. The bridge will need one at run time;')
209
+ console.log('pass --python <exe> here, or set the pythonPath option on the preset row.')
210
+ return 0
211
+ }
212
+
213
+ const version = pythonVersion(python)
214
+ console.log('')
215
+ console.log('python -> ' + python + (version === null ? '' : ' (' + version + ')'))
216
+
217
+ const check = runPython(python, [
218
+ 'import sys',
219
+ 'sys.path.insert(0, ' + JSON.stringify(DEST) + ')',
220
+ 'import matlab.engine',
221
+ 'RESULT = "ok"',
222
+ ].join('\n'))
223
+ if (check.status !== 0 || check.text === null) {
224
+ console.log('import check -> FAILED (the traceback above says why)')
225
+ return 1
226
+ }
227
+ console.log('import check -> ' + check.text.trim())
228
+ if (version !== null && !/^3\.(9|1[0-3])$/.test(version)) {
229
+ console.log('note: the engine advertises Python 3.9-3.12; elsewhere it may need an older interpreter.')
230
+ }
231
+ console.log('')
232
+ console.log('Ready. Start MATLAB from a DSH session with the matlab_session tool (action="start").')
233
+ return 0
234
+ }
235
+
236
+ main().then(
237
+ (code) => { process.exitCode = code },
238
+ (error) => {
239
+ console.error('setup-engine failed: ' + String((error && error.message) || error))
240
+ process.exitCode = 1
241
+ },
242
+ )