Skill Tracer
Skills › HyperFrames › media-use › python.mjs

python.mjs

77 lines · skills/media-use/audio/scripts/lib/python.mjs at a595755

1// python.mjs — resolve which Python 3 executable to spawn, per platform.
2//
3// The audio engine (tts.mjs, bgm.mjs) shells out to `python3` for ElevenLabs
4// TTS and the local Lyria/MusicGen BGM paths. `python3` is the right name on
5// macOS/Linux, but on Windows the python.org installer only creates
6// `python.exe` plus the `py` launcher — there is no `python3.exe` (only the
7// Microsoft Store build adds one). So a bare `spawn("python3", …)` ENOENTs on a
8// standard Windows Python install, silently disabling every Python-backed audio
9// feature until the user hand-creates a `python3.exe` shim (reported twice).
10//
11// Resolve once, per process: probe the platform's candidates in order and take
12// the first that actually runs. `py` is the launcher, so it needs a `-3` arg to
13// select Python 3 — hence candidates are argv PREFIXES, not bare names.
14//
15// `HYPERFRAMES_PYTHON` (the documented PEP 668 venv setup) wins over the PATH probe, matching
16// the CLI's `findPython()`; ignoring it made BGM silently disable itself (#4614).
18import { spawnSync } from "node:child_process";
20// Accept only an interpreter whose `--version` reports Python 3, as the CLI's
21// `validatePythonOverride()` does. Exit status alone would take a Python 2
22// `python` (or any executable that exits 0) that `doctor` rejects. Python 2
23// prints its version to stderr, so read both streams.
24export function defaultProbe(cmd, args) {
25 try {
26 const result = spawnSync(cmd, args, { encoding: "utf-8", timeout: 5000 });
27 return result.status === 0 && /Python 3/.test(`${result.stdout}${result.stderr}`);
28 } catch {
29 return false;
30 }
33/**
34 * Pick the argv prefix that launches Python 3 on this platform.
35 * Returns e.g. `["python3"]`, `["python"]`, or `["py", "-3"]`.
36 *
37 * Pure except for `probe` (which runs `<cmd> … --version`); `platform`, `probe`
38 * and `env` are injectable so every branch is unit-testable without spawning.
39 * If nothing probes OK, falls back to the canonical name for the platform so
40 * the eventual spawn fails loudly exactly as it did before — never worse.
41 */
42export function resolvePythonCommand(
43 platform = process.platform,
44 probe = defaultProbe,
45 env = process.env,
46) {
47 const override = env.HYPERFRAMES_PYTHON;
48 if (override && probe(override, ["--version"])) return [override];
49 const candidates =
50 platform === "win32" ? [["python3"], ["python"], ["py", "-3"]] : [["python3"], ["python"]];
51 for (const prefix of candidates) {
52 if (probe(prefix[0], [...prefix.slice(1), "--version"])) return prefix;
53 }
54 return candidates[0];
57let cached = null;
59/** Cached `resolvePythonCommand()` — probing spawns, so resolve at most once. */
60export function pythonCommand() {
61 if (!cached) cached = resolvePythonCommand();
62 return cached;
65/**
66 * Build a `{ cmd, args }` for running Python 3 with `extraArgs`, using the
67 * resolved (or supplied) prefix. Keeps the launcher's `-3` (and any future
68 * prefix args) ahead of the caller's own arguments.
69 */
70export function pythonInvocation(extraArgs, prefix = pythonCommand()) {
71 return { cmd: prefix[0], args: [...prefix.slice(1), ...extraArgs] };
74/** Test-only: clear the cached resolution so a test can re-probe. */
75export function _resetPythonCommandCacheForTests() {
76 cached = null;