@mcptoolshop/armature-studio 0.2.1 → 0.4.0
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 +9 -2
- package/bin/armature.mjs +230 -17
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -40,16 +40,23 @@ the Python that holds the truth.
|
|
|
40
40
|
When the toolkit is missing it says which of the two things is wrong — no interpreter, or an
|
|
41
41
|
interpreter without the package — prints the one command that fixes it, and exits non-zero.
|
|
42
42
|
|
|
43
|
-
Point it at a specific interpreter with `ARMATURE_PYTHON` if you keep several.
|
|
43
|
+
Point it at a specific interpreter with `ARMATURE_PYTHON` if you keep several. While it is
|
|
44
|
+
set it is the only interpreter tried — PATH is not searched — so a pinned interpreter without
|
|
45
|
+
the package refuses by name (exit 127) instead of quietly running a different install that
|
|
46
|
+
happens to have it.
|
|
44
47
|
|
|
45
48
|
## Commands
|
|
46
49
|
|
|
47
50
|
```bash
|
|
48
|
-
armature check #
|
|
51
|
+
armature check # resolve every module AND its function-local imports; exit 1 on any needs-*
|
|
49
52
|
armature modules # what each module is for (--json for machine output)
|
|
50
53
|
armature where # where the docs and the Blender-side scripts live
|
|
51
54
|
```
|
|
52
55
|
|
|
56
|
+
`armature --node-selftest` needs no Python at all: it proves this file parses, that the interpreter candidate list
|
|
57
|
+
is non-empty, that the Windows launcher keeps its `-3`, and that `ARMATURE_PYTHON` replaces the search order rather
|
|
58
|
+
than joining it — exit 1 with the reason on any of those. `npm test` runs it in CI, where Python may be absent.
|
|
59
|
+
|
|
53
60
|
## The rendering scripts are not here, deliberately
|
|
54
61
|
|
|
55
62
|
`render_turnaround.py` and its siblings run inside **Blender's own interpreter**:
|
package/bin/armature.mjs
CHANGED
|
@@ -15,19 +15,33 @@
|
|
|
15
15
|
* and exits non-zero.
|
|
16
16
|
*/
|
|
17
17
|
import { spawn, spawnSync } from "node:child_process";
|
|
18
|
+
import { constants } from "node:os";
|
|
18
19
|
import process from "node:process";
|
|
19
20
|
|
|
20
21
|
const PYPI = "armature-studio";
|
|
21
22
|
const DOCS = "https://mcp-tool-shop-org.github.io/armature/";
|
|
22
23
|
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Interpreter candidates, in the order worth trying on each platform.
|
|
26
|
+
*
|
|
27
|
+
* ARMATURE_PYTHON SUBSTITUTES FOR THIS LIST; IT DOES NOT JOIN IT. It used to be prepended
|
|
28
|
+
* — `[pinned, "python", "py", "python3"]` — and `locate()` kept walking until something
|
|
29
|
+
* imported the toolkit. Measured: with the pin on an interpreter that could not import
|
|
30
|
+
* `armature_core` and a different one first on PATH that could, `armature --version` printed
|
|
31
|
+
* the version and exited 0, byte-identical to the run with the variable unset. The failure
|
|
32
|
+
* message below tells the user to point at a specific interpreter with this variable, so a
|
|
33
|
+
* pin the code walks past is worse than no pin at all: the user gets the OTHER install's
|
|
34
|
+
* version and behaviour with nothing on screen saying so, which is the repo's "success while
|
|
35
|
+
* doing something else" class. A pin that cannot run the toolkit is now a refusal that names
|
|
36
|
+
* the pin, not a fall-through.
|
|
37
|
+
*/
|
|
24
38
|
function candidates() {
|
|
25
|
-
const
|
|
26
|
-
|
|
39
|
+
const pinned = process.env.ARMATURE_PYTHON;
|
|
40
|
+
if (pinned) return [pinned];
|
|
27
41
|
// `py -3` is the Windows launcher and resolves when `python3` is only the Store stub.
|
|
28
42
|
return process.platform === "win32"
|
|
29
|
-
? [
|
|
30
|
-
: [
|
|
43
|
+
? ["python", "py", "python3"]
|
|
44
|
+
: ["python3", "python"];
|
|
31
45
|
}
|
|
32
46
|
|
|
33
47
|
/** Args that turn a bare candidate into a working interpreter invocation. */
|
|
@@ -35,6 +49,52 @@ function argsFor(exe) {
|
|
|
35
49
|
return exe === "py" ? ["-3"] : [];
|
|
36
50
|
}
|
|
37
51
|
|
|
52
|
+
/**
|
|
53
|
+
* A program only a Python can run, and a token only a Python running it can print.
|
|
54
|
+
*
|
|
55
|
+
* WHY A SENTINEL AND NOT `--version`. This string decides which sentence the user is told, so
|
|
56
|
+
* it has to be a fact this launcher established rather than one it recognised. A `-V` banner
|
|
57
|
+
* is text an unrelated program can also emit; a token this file chose, printed with the
|
|
58
|
+
* running interpreter's own `sys.version_info`, is not. The version rides along because the
|
|
59
|
+
* answer to "is this a Python" is worth reporting as "which Python".
|
|
60
|
+
*/
|
|
61
|
+
const IDENT = "armature-python";
|
|
62
|
+
const IDENT_PROGRAM =
|
|
63
|
+
'import sys;sys.stdout.write("' + IDENT + ' %d.%d.%d" % sys.version_info[:3])';
|
|
64
|
+
const IDENT_SHAPE = new RegExp("(?:^|\\n)" + IDENT + " (\\d+\\.\\d+\\.\\d+)");
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* QUESTION ONE: is this candidate a Python at all? Returns its version, or null.
|
|
68
|
+
*
|
|
69
|
+
* WHAT THIS REPLACES. `locate()` set `sawInterpreter = true` for any candidate whose spawn did
|
|
70
|
+
* not `error` — `if (probe.error) continue; sawInterpreter = true;` — so "this thing is a
|
|
71
|
+
* Python" was decided by "this thing could be started". Measured on a Windows rig with the
|
|
72
|
+
* file as shipped: a copy of `where.exe` renamed `python.exe`, alone on PATH beside node, made
|
|
73
|
+
* `armature --version` print "Python is installed, but the armature-studio toolkit is not
|
|
74
|
+
* importable from it." and tell the user to `pip install armature-studio` — on a machine with
|
|
75
|
+
* no Python and no pip anywhere. `ARMATURE_PYTHON` pointed at the same file said the same
|
|
76
|
+
* thing about the pin.
|
|
77
|
+
*
|
|
78
|
+
* That shape is not hypothetical, and the comment on `candidates()` above already names it:
|
|
79
|
+
* the Windows Store App Execution Alias IS an executable called `python.exe` that starts,
|
|
80
|
+
* prints an install notice and exits non-zero. It is the most common state a Windows machine
|
|
81
|
+
* with no Python is in, and it is exactly the state this check exists to report correctly.
|
|
82
|
+
*
|
|
83
|
+
* A non-zero exit is "not an interpreter", not "a broken interpreter": nothing that can run
|
|
84
|
+
* Python fails to run a `write`. The stdout is matched against the sentinel as well, because
|
|
85
|
+
* an exit code of 0 is also something an unrelated program can produce.
|
|
86
|
+
*/
|
|
87
|
+
function pythonVersion(exe, pre) {
|
|
88
|
+
const probe = spawnSync(exe, [...pre, "-c", IDENT_PROGRAM], {
|
|
89
|
+
encoding: "utf8",
|
|
90
|
+
shell: false,
|
|
91
|
+
});
|
|
92
|
+
if (probe.error) return null; // not on PATH at all
|
|
93
|
+
if (probe.status !== 0) return null; // it started, and it is not a Python
|
|
94
|
+
const match = `${probe.stdout ?? ""}`.match(IDENT_SHAPE);
|
|
95
|
+
return match ? match[1] : null; // it exited 0 without being a Python
|
|
96
|
+
}
|
|
97
|
+
|
|
38
98
|
/**
|
|
39
99
|
* Find an interpreter that can actually import the toolkit.
|
|
40
100
|
*
|
|
@@ -42,36 +102,114 @@ function argsFor(exe) {
|
|
|
42
102
|
* is a DIFFERENT problem from no interpreter at all, and telling them apart is the whole
|
|
43
103
|
* value of this check. Reporting "python not found" to someone who has three Pythons and
|
|
44
104
|
* no package would send them fixing the wrong thing.
|
|
105
|
+
*
|
|
106
|
+
* The two questions are now asked with two probes, in that order, because a single
|
|
107
|
+
* `import armature_core` spawn could only ever answer the second one — see `pythonVersion`
|
|
108
|
+
* above for what it was reading as "Python is installed".
|
|
45
109
|
*/
|
|
46
110
|
function locate() {
|
|
47
111
|
let sawInterpreter = false;
|
|
48
112
|
for (const exe of candidates()) {
|
|
49
113
|
const pre = argsFor(exe);
|
|
114
|
+
// QUESTION ONE. A candidate that is not a Python leaves `sawInterpreter` alone, so the
|
|
115
|
+
// refusal below stays "no interpreter was found" rather than becoming a claim about a
|
|
116
|
+
// toolkit missing from something that could never have imported it.
|
|
117
|
+
if (pythonVersion(exe, pre) === null) continue;
|
|
118
|
+
sawInterpreter = true;
|
|
119
|
+
// QUESTION TWO.
|
|
50
120
|
const probe = spawnSync(exe, [...pre, "-c", "import armature_core"], {
|
|
51
121
|
stdio: "ignore",
|
|
52
122
|
shell: false,
|
|
53
123
|
});
|
|
54
|
-
|
|
55
|
-
sawInterpreter
|
|
56
|
-
|
|
124
|
+
// `sawInterpreter` rides the SUCCESS shape too. It used to be on the failure shape only,
|
|
125
|
+
// and `fail()` branches on it: handed a success object, `found.sawInterpreter` was
|
|
126
|
+
// undefined and the launcher reported "No Python interpreter was found on PATH" about an
|
|
127
|
+
// interpreter it had just imported the toolkit with. A caller cannot read a fact off a
|
|
128
|
+
// shape that only carries it when the answer is no.
|
|
129
|
+
if (!probe.error && probe.status === 0) return { exe, pre, sawInterpreter: true };
|
|
57
130
|
}
|
|
58
131
|
return { exe: null, pre: null, sawInterpreter };
|
|
59
132
|
}
|
|
60
133
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
134
|
+
/**
|
|
135
|
+
* The refusal. `err` is present only when an interpreter was FOUND and then would not start.
|
|
136
|
+
*
|
|
137
|
+
* That distinction is the whole point of the two-question probe above: telling someone whose
|
|
138
|
+
* Python is fine, and whose toolkit is installed, to `pip install` the toolkit sends them
|
|
139
|
+
* fixing the one thing that is not broken — and reinstalling changes nothing. The spawn fails
|
|
140
|
+
* after a successful probe for reasons that have nothing to do with either: EACCES/EPERM from
|
|
141
|
+
* an AV or policy hook, the interpreter renamed or unmounted between the two calls, EMFILE.
|
|
142
|
+
*/
|
|
143
|
+
function fail(found, err) {
|
|
144
|
+
const pinned = process.env.ARMATURE_PYTHON;
|
|
145
|
+
if (err) {
|
|
146
|
+
process.stderr.write(
|
|
147
|
+
`armature: could not start ${found.exe}: ${err.code ?? err.message}\n\n` +
|
|
148
|
+
` That interpreter WAS found and it imports the ${PYPI} toolkit — the probe ran and\n` +
|
|
149
|
+
` succeeded. Something stopped this process from launching it: a security or policy\n` +
|
|
150
|
+
` hook, a file that moved between the probe and the launch, or a process limit.\n` +
|
|
151
|
+
` Reinstalling ${PYPI} will not change this.\n\n` +
|
|
152
|
+
` Docs: ${DOCS}\n`
|
|
153
|
+
);
|
|
154
|
+
process.exit(127);
|
|
155
|
+
}
|
|
156
|
+
// A pinned run and an unpinned one fail for different reasons, and saying "no Python was
|
|
157
|
+
// found on PATH" to someone who pinned one would send them fixing the wrong thing — the
|
|
158
|
+
// same distinction `locate()` draws between "no interpreter" and "no package".
|
|
159
|
+
const what = pinned
|
|
160
|
+
? found.sawInterpreter
|
|
161
|
+
? `ARMATURE_PYTHON is set to ${pinned}, and the ${PYPI} toolkit is not importable from it.`
|
|
162
|
+
: `ARMATURE_PYTHON is set to ${pinned}, which this shell could not run.`
|
|
163
|
+
: found.sawInterpreter
|
|
164
|
+
? `Python is installed, but the ${PYPI} toolkit is not importable from it.`
|
|
165
|
+
: "No Python interpreter was found on PATH.";
|
|
166
|
+
const hint = pinned
|
|
167
|
+
? ` That pin is the ONLY interpreter tried — PATH is not searched while it is set.\n` +
|
|
168
|
+
` Install the toolkit into it, or unset ARMATURE_PYTHON to search PATH again.\n`
|
|
169
|
+
: ` Point at a specific interpreter with ARMATURE_PYTHON if you use one.\n`;
|
|
65
170
|
process.stderr.write(
|
|
66
171
|
`armature: ${what}\n\n` +
|
|
67
172
|
` This package is a launcher. The toolkit itself is Python:\n\n` +
|
|
68
173
|
` pip install ${PYPI}\n\n` +
|
|
69
|
-
|
|
174
|
+
hint +
|
|
70
175
|
` Docs: ${DOCS}\n`
|
|
71
176
|
);
|
|
72
177
|
process.exit(127);
|
|
73
178
|
}
|
|
74
179
|
|
|
180
|
+
/**
|
|
181
|
+
* The exit code a caller should see for a child that ended with `code` / `signal`.
|
|
182
|
+
*
|
|
183
|
+
* THE COLLAPSE THIS REPLACES. The handler was `signal ? 1 : code ?? 0`, so every signal
|
|
184
|
+
* death arrived at the caller as 1 — byte-identical to an ordinary Python traceback. The
|
|
185
|
+
* rest of this repository spends real effort on that distinction: 2 is a refusal, 1 is a
|
|
186
|
+
* crash (`tests/test_packaging.py` pins that convention on the CPU tools), and the realistic
|
|
187
|
+
* signal here is the OOM killer taking the Python process during a frame or GLB pass. A
|
|
188
|
+
* wrapper that retries a crash and halts on a kill could not tell them apart, so an
|
|
189
|
+
* OOM-killed run was retried into the same wall with nothing naming the signal.
|
|
190
|
+
*
|
|
191
|
+
* 128 + N is the shell convention, which is what a caller already knows how to read — a
|
|
192
|
+
* shell reports exactly this for its own killed children. A signal Node names but this
|
|
193
|
+
* platform does not number falls back to 1 rather than inventing a code.
|
|
194
|
+
*/
|
|
195
|
+
function exitCodeFor(code, signal) {
|
|
196
|
+
const number = signal ? constants.signals[signal] : undefined;
|
|
197
|
+
if (signal) return number ? 128 + number : 1;
|
|
198
|
+
return code ?? 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Say which signal ended the run, because the exit code alone is a number to look up. */
|
|
202
|
+
function reportSignal(signal) {
|
|
203
|
+
process.stderr.write(
|
|
204
|
+
`armature: the ${PYPI} process was killed by ${signal} ` +
|
|
205
|
+
`(exit ${exitCodeFor(null, signal)}).\n\n` +
|
|
206
|
+
` Nothing in Python refused: the process was terminated from outside. On a long\n` +
|
|
207
|
+
` frame or GLB pass the usual cause is the OOM killer. Retrying without changing\n` +
|
|
208
|
+
` anything will meet the same limit.\n\n` +
|
|
209
|
+
` Docs: ${DOCS}\n`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
75
213
|
const argv = process.argv.slice(2);
|
|
76
214
|
|
|
77
215
|
// A self-test that does not need Python present: it proves this file parses, resolves its
|
|
@@ -86,7 +224,75 @@ if (argv[0] === "--node-selftest") {
|
|
|
86
224
|
process.stderr.write("selftest: the Windows launcher lost its -3\n");
|
|
87
225
|
process.exit(1);
|
|
88
226
|
}
|
|
89
|
-
|
|
227
|
+
// The pin is checked here because `npm test` is the launcher's only coverage in CI, and
|
|
228
|
+
// the pin was prepended to the PATH walk rather than replacing it — a run that honours
|
|
229
|
+
// the variable and a run that ignores it are indistinguishable from the outside unless
|
|
230
|
+
// something asserts the candidate list itself.
|
|
231
|
+
const saved = process.env.ARMATURE_PYTHON;
|
|
232
|
+
try {
|
|
233
|
+
process.env.ARMATURE_PYTHON = "/armature/selftest/pinned-python";
|
|
234
|
+
const pinnedList = candidates();
|
|
235
|
+
if (pinnedList.length !== 1 || pinnedList[0] !== "/armature/selftest/pinned-python") {
|
|
236
|
+
process.stderr.write(
|
|
237
|
+
`selftest: ARMATURE_PYTHON did not substitute for the search order — ` +
|
|
238
|
+
`candidates were: ${pinnedList.join(", ")}\n`
|
|
239
|
+
);
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
} finally {
|
|
243
|
+
if (saved === undefined) delete process.env.ARMATURE_PYTHON;
|
|
244
|
+
else process.env.ARMATURE_PYTHON = saved;
|
|
245
|
+
}
|
|
246
|
+
// THE TWO-QUESTION PROBE, exercised where there is no Python to exercise it with. `node`
|
|
247
|
+
// itself is an executable that starts and exits non-zero for the sentinel program (`-c` is
|
|
248
|
+
// `--check` and wants a file), which is precisely the shape that used to be read as "Python
|
|
249
|
+
// is installed": the Store stub, a `where.exe` renamed `python.exe`, anything on PATH under
|
|
250
|
+
// that name. Driven through `locate()` rather than through the probe alone, because
|
|
251
|
+
// `sawInterpreter` is the operand `fail()` branches on, and the pin substitutes for the
|
|
252
|
+
// whole candidate list — so this asks the real question of the real function.
|
|
253
|
+
const pinSaved = process.env.ARMATURE_PYTHON;
|
|
254
|
+
try {
|
|
255
|
+
process.env.ARMATURE_PYTHON = process.execPath;
|
|
256
|
+
const probed = locate();
|
|
257
|
+
if (probed.exe !== null || probed.sawInterpreter !== false) {
|
|
258
|
+
process.stderr.write(
|
|
259
|
+
`selftest: a non-Python executable (${process.execPath}) was read as an interpreter ` +
|
|
260
|
+
`— exe=${probed.exe}, sawInterpreter=${probed.sawInterpreter}.\n` +
|
|
261
|
+
` Someone with no Python at all would be told to pip install the toolkit, with no\n` +
|
|
262
|
+
` pip to run it with.\n`
|
|
263
|
+
);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
} finally {
|
|
267
|
+
if (pinSaved === undefined) delete process.env.ARMATURE_PYTHON;
|
|
268
|
+
else process.env.ARMATURE_PYTHON = pinSaved;
|
|
269
|
+
}
|
|
270
|
+
// The signal mapping, checked here because `npm test` is the launcher's only coverage in
|
|
271
|
+
// CI and there is no Python to kill on a runner. A launcher that honours the convention
|
|
272
|
+
// and one that collapses every signal into 1 are indistinguishable from outside unless
|
|
273
|
+
// something asserts the mapping itself — the same reason the pin is checked above.
|
|
274
|
+
const mapping = [
|
|
275
|
+
["SIGKILL", 137],
|
|
276
|
+
["SIGTERM", 143],
|
|
277
|
+
["SIGINT", 130],
|
|
278
|
+
];
|
|
279
|
+
for (const [name, expected] of mapping) {
|
|
280
|
+
const got = exitCodeFor(null, name);
|
|
281
|
+
if (got !== expected) {
|
|
282
|
+
process.stderr.write(`selftest: ${name} maps to ${got}, not ${expected}\n`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// The half that must not move while the half above is added: the child's own exit code
|
|
287
|
+
// still reaches the caller, and a refusal (2) is still distinct from a crash (1).
|
|
288
|
+
if (exitCodeFor(0, null) !== 0 || exitCodeFor(2, null) !== 2 || exitCodeFor(1, null) !== 1) {
|
|
289
|
+
process.stderr.write("selftest: the child's exit code no longer reaches the caller\n");
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
const reported = mapping.map(([name, code]) => `${name}=${code}`).join(", ");
|
|
293
|
+
process.stdout.write(
|
|
294
|
+
`armature launcher ok — candidates: ${list.join(", ")}; signal exits: ${reported}\n`
|
|
295
|
+
);
|
|
90
296
|
process.exit(0);
|
|
91
297
|
}
|
|
92
298
|
|
|
@@ -94,10 +300,17 @@ const found = locate();
|
|
|
94
300
|
if (!found.exe) fail(found);
|
|
95
301
|
|
|
96
302
|
// Forward everything verbatim and inherit the child's exit code, so a gate that raises in
|
|
97
|
-
// Python still fails the shell that called this launcher.
|
|
303
|
+
// Python still fails the shell that called this launcher. A child that did not exit on its
|
|
304
|
+
// own has no exit code to inherit, so its signal is named and reported as 128 + N rather
|
|
305
|
+
// than erased into the crash code — see `exitCodeFor` above.
|
|
98
306
|
const child = spawn(found.exe, [...found.pre, "-m", "armature_core.cli", ...argv], {
|
|
99
307
|
stdio: "inherit",
|
|
100
308
|
shell: false,
|
|
101
309
|
});
|
|
102
|
-
child.on("exit", (code, signal) =>
|
|
103
|
-
|
|
310
|
+
child.on("exit", (code, signal) => {
|
|
311
|
+
if (signal) reportSignal(signal);
|
|
312
|
+
process.exit(exitCodeFor(code, signal));
|
|
313
|
+
});
|
|
314
|
+
// The error is carried in, not dropped: `fail(found)` with no error reports "no interpreter",
|
|
315
|
+
// which is a claim about a probe that had already succeeded.
|
|
316
|
+
child.on("error", (e) => fail(found, e));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcptoolshop/armature-studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "You block the shot; the model shoots it — GLB-authored previz, control sequences and gates for video-diffusion generation. Node launcher for the armature-studio Python toolkit.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"previz",
|
|
@@ -37,7 +37,6 @@
|
|
|
37
37
|
"test": "node bin/armature.mjs --node-selftest"
|
|
38
38
|
},
|
|
39
39
|
"publishConfig": {
|
|
40
|
-
"access": "public"
|
|
41
|
-
"provenance": true
|
|
40
|
+
"access": "public"
|
|
42
41
|
}
|
|
43
42
|
}
|