@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 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 # import every module and report what resolved
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
- /** Interpreter candidates, in the order worth trying on each platform. */
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 fromEnv = process.env.ARMATURE_PYTHON;
26
- const list = fromEnv ? [fromEnv] : [];
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
- ? [...list, "python", "py", "python3"]
30
- : [...list, "python3", "python"];
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
- if (probe.error) continue; // this candidate is not on PATH at all
55
- sawInterpreter = true;
56
- if (probe.status === 0) return { exe, pre };
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
- function fail(found) {
62
- const what = found.sawInterpreter
63
- ? `Python is installed, but the ${PYPI} toolkit is not importable from it.`
64
- : "No Python interpreter was found on PATH.";
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
- ` Point at a specific interpreter with ARMATURE_PYTHON if you use one.\n` +
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
- process.stdout.write(`armature launcher ok candidates: ${list.join(", ")}\n`);
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) => process.exit(signal ? 1 : code ?? 0));
103
- child.on("error", () => fail(found));
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.2.1",
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
  }