Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion packages/cli/src/tts/python.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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");
});
});
41 changes: 26 additions & 15 deletions packages/cli/src/tts/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
heygengenesis[bot] marked this conversation as resolved.
Resolved
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.
Expand All @@ -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;
}
25 changes: 24 additions & 1 deletion packages/cli/src/tts/synthesize.cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}));
Expand Down Expand Up @@ -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);
Expand Down
20 changes: 11 additions & 9 deletions packages/cli/src/tts/synthesize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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");
}

Expand All @@ -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,
},
);

Expand Down
Loading