fix(cli): text-to-speech ignores a foreign PYTHONPATH when it looks for kokoro-onnx (#6769)

* fix(cli): text-to-speech ignores a foreign PYTHONPATH when it looks for kokoro-onnx

* fix(cli): shorten the pythonEnv comment

* fix(cli): text-to-speech keeps a deliberate PYTHONPATH unless the packages fail with it

* fix(cli): drop the python.ts comments the names already say
This commit is contained in:
Miguel Ángel
2026-10-30 02:25:39 +00:00
committed by GitHub
parent 471d60a8ac
commit d1c0593717
4 changed files with 123 additions and 26 deletions
+62 -1
View File
@@ -15,7 +15,13 @@ vi.mock("node:child_process", async (importOriginal) => {
return { ...actual, execFileSync: execFileSyncMock };
});
const { findPython, describeRejectedPythonOverride } = await import("./python.js");
const {
findPython,
describeRejectedPythonOverride,
hasPythonPackage,
hasPythonModules,
workingPythonEnv,
} = await import("./python.js");
describe("findPython / describeRejectedPythonOverride", () => {
beforeEach(() => {
@@ -76,3 +82,58 @@ describe("findPython / describeRejectedPythonOverride", () => {
expect(execFileSyncMock).toHaveBeenCalledTimes(1);
});
});
describe("PYTHONPATH when probing for Python packages", () => {
const PYTHON = "/opt/venv/bin/python";
// The import works only for the environment `works` accepts, like a real interpreter would.
function importsWhen(works: (env: NodeJS.ProcessEnv) => boolean) {
execFileSyncMock.mockReset();
execFileSyncMock.mockImplementation((_cmd: string, args: string[], options: any) => {
if (args[0] === "-c" && !works(options.env)) throw new Error("ModuleNotFoundError");
return "Python 3.11.0";
});
}
beforeEach(() => {
process.env.HYPERFRAMES_PYTHON = PYTHON;
});
afterEach(() => {
delete process.env.PYTHONPATH;
delete process.env.HYPERFRAMES_PYTHON;
});
it("keeps a PYTHONPATH the user set on purpose when the package needs it", () => {
process.env.PYTHONPATH = "/deliberate/site-packages";
importsWhen((env) => env.PYTHONPATH === "/deliberate/site-packages");
expect(hasPythonPackage(PYTHON, "kokoro_onnx")).toBe(true);
expect(hasPythonModules(["kokoro_onnx"])).toBe(true);
expect(workingPythonEnv(PYTHON, ["kokoro_onnx"], { A: "1" })).toMatchObject({
PYTHONPATH: "/deliberate/site-packages",
A: "1",
});
});
it("drops a foreign PYTHONPATH that stops the package from importing", () => {
process.env.PYTHONPATH = "/other/python/site-packages";
importsWhen((env) => env.PYTHONPATH === undefined);
expect(hasPythonPackage(PYTHON, "kokoro_onnx")).toBe(true);
expect(hasPythonModules(["kokoro_onnx"])).toBe(true);
const env = workingPythonEnv(PYTHON, ["kokoro_onnx"], { ORT_DISABLE_TELEMETRY: "1" });
expect(env).not.toHaveProperty("PYTHONPATH");
expect(env).toMatchObject({ PATH: process.env.PATH, ORT_DISABLE_TELEMETRY: "1" });
});
it("tries the inherited environment first and reports a package neither finds", () => {
process.env.PYTHONPATH = "/other/python/site-packages";
importsWhen(() => false);
expect(hasPythonPackage(PYTHON, "kokoro_onnx")).toBe(false);
expect(workingPythonEnv(PYTHON, ["kokoro_onnx"])).toBeNull();
const [first] = execFileSyncMock.mock.calls.filter(([, args]) => args[0] === "-c");
expect(first?.[2].env.PYTHONPATH).toBe("/other/python/site-packages");
});
});
+26 -15
View File
@@ -77,19 +77,38 @@ export function describeRejectedPythonOverride(): string | null {
return `HYPERFRAMES_PYTHON="${override}" was rejected: ${validation.reason}`;
}
/** True if `import <pkg>` succeeds — actually executes the module. */
export function hasPythonPackage(python: string, pkg: string): boolean {
function withoutPythonPath(): NodeJS.ProcessEnv {
const { PYTHONPATH: _foreign, ...env } = process.env;
return env;
}
function runs(python: string, code: string, env: NodeJS.ProcessEnv): boolean {
try {
execFileSync(python, ["-c", `import ${pkg}`], {
stdio: ["pipe", "pipe", "pipe"],
timeout: 10_000,
});
execFileSync(python, ["-c", code], { stdio: ["pipe", "pipe", "pipe"], timeout: 10_000, env });
return true;
} catch {
return false;
}
}
function environmentThatRuns(python: string, code: string): NodeJS.ProcessEnv | null {
return [process.env, withoutPythonPath()].find((env) => runs(python, code, env)) ?? null;
}
export function workingPythonEnv(
python: string,
modules: string[],
extra: NodeJS.ProcessEnv = {},
): NodeJS.ProcessEnv | null {
const env = environmentThatRuns(python, `import ${modules.join(", ")}`);
return env && { ...env, ...extra };
}
/** True if `import <pkg>` succeeds — actually executes the module. */
export function hasPythonPackage(python: string, pkg: string): boolean {
return environmentThatRuns(python, `import ${pkg}`) !== null;
}
/**
* True if every module is installed, checked via `importlib.util.find_spec`
* so heavy packages (torch) are never imported — fast enough for a preflight.
@@ -100,13 +119,5 @@ export function hasPythonModules(modules: string[]): boolean {
if (!python) return false;
const list = JSON.stringify(modules);
const probe = `import importlib.util,sys; sys.exit(0 if all(importlib.util.find_spec(m) for m in ${list}) else 1)`;
try {
execFileSync(python, ["-c", probe], {
stdio: ["pipe", "pipe", "pipe"],
timeout: 10_000,
});
return true;
} catch {
return false;
}
return environmentThatRuns(python, probe) !== null;
}
+24 -1
View File
@@ -14,7 +14,8 @@ vi.mock("node:fs", async (importOriginal) => {
return { ...original, existsSync: vi.fn(original.existsSync) };
});
vi.mock("node:child_process", () => ({ execFileSync: vi.fn() }));
vi.mock("./python.js", () => ({
vi.mock("./python.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./python.js")>()),
findPython: () => "test-python",
hasPythonPackage: () => true,
}));
@@ -53,6 +54,28 @@ describe("synthesis script cache", () => {
fs.rmSync(paths.home, { recursive: true, force: true });
});
it("runs the synthesis process in the environment the packages imported under", async () => {
vi.mocked(execFileSync).mockImplementation((_cmd, args, options) => {
const importing = String(args?.[0]) === "-c";
const env = (options as { env?: NodeJS.ProcessEnv }).env;
if (importing && env?.PYTHONPATH !== undefined) throw new Error("ModuleNotFoundError");
fs.writeFileSync(outputPath, "stub audio");
return JSON.stringify({
outputPath,
sampleRate: 24000,
durationSeconds: 1,
langApplied: true,
});
});
vi.stubEnv("PYTHONPATH", "/other/python/site-packages");
const { synthesize } = await import("./synthesize.js");
await synthesize("Hello", outputPath);
const options = vi.mocked(execFileSync).mock.calls.at(-1)?.[2];
expect(options?.env).not.toHaveProperty("PYTHONPATH");
vi.unstubAllEnvs();
});
it("turns off onnxruntime telemetry for the synthesis process", async () => {
const { synthesize } = await import("./synthesize.js");
await synthesize("Hello", outputPath);
+11 -9
View File
@@ -10,7 +10,7 @@ import {
inferLangFromVoiceId,
type SupportedLang,
} from "./manager.js";
import { findPython, hasPythonPackage } from "./python.js";
import { findPython, hasPythonPackage, workingPythonEnv } from "./python.js";
import { writeNewFileSync } from "../utils/writeNewFile.js";
// ---------------------------------------------------------------------------
@@ -189,13 +189,15 @@ export async function synthesize(
);
}
if (!hasPythonPackage(python, "kokoro_onnx")) {
throw new Error(
"The kokoro-onnx package is not installed. Run: pip install kokoro-onnx soundfile (or point HYPERFRAMES_PYTHON at a venv python that has them)",
);
}
if (!hasPythonPackage(python, "soundfile")) {
const pythonEnv = workingPythonEnv(python, ["kokoro_onnx", "soundfile"], {
ORT_DISABLE_TELEMETRY: "1",
});
if (!pythonEnv) {
if (!hasPythonPackage(python, "kokoro_onnx")) {
throw new Error(
"The kokoro-onnx package is not installed. Run: pip install kokoro-onnx soundfile (or point HYPERFRAMES_PYTHON at a venv python that has them)",
);
}
throw new Error("The soundfile package is not installed. Run: pip install soundfile");
}
@@ -222,7 +224,7 @@ export async function synthesize(
encoding: "utf-8",
timeout: 300_000,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, ORT_DISABLE_TELEMETRY: "1" },
env: pythonEnv,
},
);