mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user